在本系列文即時行情監控的主題,我們實作了 Monitor 應用伺服器,以富果行情 API 結合 LINE Notify 為例,實現股票即時行情監控系統。在前一天,我們也實作出 Trader 應用伺服器,可以透過富果交易 API 進行下單委託、帳務查詢等功能,實現一個程式交易系統。今天我們要整合 Monitor 與 Trader 應用伺服器,透過富果行情與交易 API,打造一個自動化下單的服務。
為了理解整個自動化下單的運作流程,我們先描繪出 Monitor 與 Trader 應用伺服器的系統環境圖:
在 Monitor 與 Trader 應用伺服器,主要包含以下元件:
在 Monitor 與 Trader 應用伺服器處理資料的流程可以分成三大部分,包含使用者向 Monitor API 請求建立觸價委託、 Monitor Service 接收 Fugle Realtime 即時行情的資料處理,以及 Monitor Service 向 Trader API 下達觸價委託指令。
使用者向 Monitor API 請求建立觸價委託:
Monitor Service 接收 Fugle Realtime 即時行情::
Monitor Service 向 Trader API 請求下單委託::
為了讓 Monitor 應用程式存取 Trader 應用程式的服務,我們會將 Trader 應用伺服器調整為混合式應用程式(hybrid application)既可以監聽 HTTP 請求,又可以使用連接的微服務(microservice)。
Nest Framework 支持微服務(microservice)架構風格的開發,在 Nest 中,微服務是使用與 HTTP 不同的傳輸層(transport)的應用程式。Nest 支援幾種內建的傳輸層實現,稱為傳輸器(transporters),它們負責在不同的微服務實體之間傳輸訊息。其中 Nest 的 Redis 傳輸器是利用 Redis 的 Pub/Sub 特性,實現在不同服務之間的訊息傳遞。由於我們已經安裝 Redis,所以我們就使用 Redis 作為微服務傳輸器。
為了開始建立微服務,首先安裝需要的套件:
$ npm install --save @nestjs/microservices
註:在目前最新的 Nest v9 版本中,已經將 Redis 傳輸器的依賴改為使用 ioredis 套件,在本系列文前面的主題已經安裝過該套件。如果是使用 Nest v8 以前的版本,則需要安裝 redis 套件作為 Redis 傳輸器的依賴。
安裝完成後,先在專案目錄下開啟 .env
檔案,加入 Redis 連線位址的環境變數設定:
REDIS_HOST=
REDIS_PORT=
設定環境變數後,開啟 apps/trader/src/main.ts
檔案,匯入 @nestjs/microservices
模組,使用 connectMicroservice()
方法連接微服務,並設定 Redis 作為微服務傳輸器。
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.REDIS,
options: {
host: process.env.REDIS_HOST,
port: +process.env.REDIS_PORT,
},
});
await app.startAllMicroservices();
await app.listen(3001);
}
bootstrap();
在測試期間,為了避免和
monotor
應用程式使用的 port 衝突,我們將trader
應用程式的 port 改為3001
。
然後開啟 apps/trader/src/trader/trader.controller.ts
檔案,在 TraderController
加入 handleOrder()
方法,處理微服務請求:
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import { TraderService } from './trader.service';
import { PlaceOrderDto } from './dto/place-order.dto';
import { ReplaceOrderDto } from './dto/replace-order.dto';
import { GetTransactionsDto } from './dto/get-transactions.dto';
@Controller('trader')
export class TraderController {
constructor(private readonly traderService: TraderService) {}
...
@EventPattern('place-order')
async handleOrder(@Payload() placeOrderDto) {
return this.traderService.placeOrder(placeOrderDto);
}
}
在 handleOrder()
方法使用 @EventPattern()
裝飾器,定義基於事件(event-based)的微服務介面。當收到請求後,Trader 應用程式就會執行下單委託。
完成 Trader 應用程式的微服務後,我們要在 libs/common
定義常數代表 Trader 應用程式提供的微服務,方便其他 Nest 應用程式引用。在 libs/common/src
目錄下建立 constants.ts
檔案,並開啟該檔案加入:
export const TRADER_SERVICE = 'TRADER_SERVICE';
然後開啟 libs/common/src/index.ts
檔案,將 constants.ts
匯出:
export * from './constants';
在其他 Nest 應用程式中就可以透過以下方式引用 TRADER_SERVICE
:
import { TRADER_SERVICE } from '@speculator/common';
為了在 Monitor 應用程式調用 Trader 應用程式的微服務,請開啟 apps/trader/src/monitor/monitor.module.ts
檔案,我們在 MonitorModule
匯入 ClientsModule
註冊微服務設定:
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { MongooseModule } from '@nestjs/mongoose';
import { FugleRealtimeModule } from '@fugle/realtime-nest';
import { TRADER_SERVICE } from '@speculator/common';
import { Monitor, MonitorSchema } from './monitor.schema';
import { MonitorRepository } from './monitor.repository';
import { MonitorService } from './monitor.service';
import { MonitorController } from './monitor.controller';
@Module({
imports: [
ClientsModule.registerAsync([{
name: TRADER_SERVICE,
useFactory: () => ({
transport: Transport.REDIS,
options: {
host: process.env.REDIS_HOST,
port: +process.env.REDIS_PORT,
},
}),
}]),
MongooseModule.forFeature([
{ name: Monitor.name, schema: MonitorSchema },
]),
FugleRealtimeModule.registerAsync({
useFactory: () => ({
apiToken: process.env.FUGLE_REALTIME_API_TOKEN,
}),
}),
],
providers: [MonitorRepository, MonitorService],
controllers: [MonitorController],
})
export class MonitorModule { }
我們要在 Monitor 應用程式新增觸價委託 API,為了實現這個功能,我們需要調整 MonitorSchema
。開啟 apps/monitor/src/monitor/monitor.schema.ts
檔案,在 MonitorSchema
新增 order
欄位,代表下單委託的設定:
import { Prop, raw, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
export type MonitorDocument = Monitor & Document;
@Schema({ timestamps: true })
export class Monitor {
@Prop()
symbol: string;
@Prop()
type: string;
@Prop()
value: string;
@Prop(raw({
title: { type: String },
message: { type: String },
}))
alert: Record<string, string>;
// 新增 order 欄位
@Prop(raw({
stockNo: { type: String },
buySell: { type: String },
price: { type: Number },
quantity: { type: Number },
apCode: { type: String },
priceFlag: { type: String },
bsFlag: { type: String },
trade: { type: String },
}))
order: Record<string, string | number>;
@Prop({ default: false })
triggered: boolean;
}
export const MonitorSchema = SchemaFactory.createForClass(Monitor);
在 MonitorSchema
新增的 order
物件包含的欄位說明如下:
stockNo
:股票代號。buySell
:買賣別。price
:委託價格。quantity
:委託數量。apCode
:盤別。如:整股、盤中零股、盤後定價、盤後零股、興櫃。priceFlag
:價格類型。如:限價、市價、平盤價、跌停價、漲停價。bsFlag
:訂單類別。如:ROD、IOC、FOK。trade
:交易類別。如:現股、融資、融券等。完成 MonitorSchema
後,我們開啟 apps/monitor/src/monitor/monitor.repository.ts
檔案,實作 MonitorRepository
加入以下方法:
getOrders()
:取得所有觸價委託。createOrder()
:建立一個觸價委託。getOrder()
:取得一個觸價委託。removeOrder()
:刪除一個觸價委託。實作 MonitorRepository
方法如下:
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Monitor, MonitorDocument } from './monitor.schema';
import { CreateAlertDto } from './dto/create-alert.dto';
import { CreateOrderDto } from './dto/create-order.dto';
@Injectable()
export class MonitorRepository {
constructor(
@InjectModel(Monitor.name) private readonly model: Model<MonitorDocument>,
) {}
...
async getOrders(): Promise<MonitorDocument[]> {
return this.model.find({ order: { $exists: true } })
.select('-__v -createdAt -updatedAt')
.lean();
}
async createOrder(createOrderDto: CreateOrderDto): Promise<MonitorDocument> {
const { order, ...monitorable } = createOrderDto;
const monitor = { ...monitorable, order: JSON.parse(order) }
return this.model.create(monitor);
}
async getOrder(id: string): Promise<MonitorDocument> {
return this.model
.findOne({ _id: id, order: { $exists: true } })
.select('-__v -createdAt -updatedAt')
.lean();
}
async removeOrder(id: string): Promise<void> {
await this.model.deleteOne({ _id: id, order: { $exists: true } });
}
}
在 createOrder()
方法中,接收參數 CreateOrderDto
是資料傳輸物件(Data Transfer Object)用來建立觸價委託設定。請在 apps/monitor/src/monitor/dto
目錄下建立 create-order.dto.ts
檔案,然後開啟該檔案並實作 CreateOrderDto
資料傳輸物件:
import { IsString, IsNumber, IsEnum, IsJSON } from 'class-validator';
import { MonitorType } from '../enums';
export class CreateOrderDto {
@IsString()
symbol: string;
@IsEnum(MonitorType)
type: MonitorType;
@IsNumber()
value: number;
@IsJSON()
order: string;
}
我們需要調整 MonitorService
新增處理觸價委託的方法。開啟 apps/monitor/src/monitor/monitor.service.ts
,將 MonitorService
修改如下:
import { omit } from 'lodash';
import { DateTime } from 'luxon';
import { Redis } from 'ioredis';
import { Injectable, Inject, Logger, OnApplicationBootstrap, NotFoundException, ForbiddenException } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { InjectRedis } from '@liaoliaots/nestjs-redis';
import { InjectWebSocketClient } from '@fugle/realtime-nest';
import { InjectLineNotify, LineNotify } from 'nest-line-notify';
import { WebSocketClient } from '@fugle/realtime';
import { TRADER_SERVICE } from '@speculator/common';
import { MonitorRepository } from './monitor.repository';
import { MonitorDocument } from './monitor.schema';
import { CreateAlertDto } from './dto/create-alert.dto';
import { CreateOrderDto } from './dto/create-order.dto';
@Injectable()
export class MonitorService implements OnApplicationBootstrap {
private readonly sockets = new Map<string, WebSocket>();
constructor(
@Inject(TRADER_SERVICE) private readonly traderService: ClientProxy,
@InjectRedis() private readonly redis: Redis,
@InjectWebSocketClient() private readonly client: WebSocketClient,
@InjectLineNotify() private readonly lineNotify: LineNotify,
private readonly monitorRepository: MonitorRepository,
) {}
async onApplicationBootstrap() {
// 取得所有未觸發的監控設定並進行監控
const monitors = await this.monitorRepository.getMonitors();
await Promise.all(monitors.map(monitor => this.makeMonitoring(monitor)))
}
...
async getOrders() {
// 取得所有觸價委託
return this.monitorRepository.getOrders();
}
async createOrder(createOrderDto: CreateOrderDto) {
// 建立觸價委託並進行監控
const monitor = await this.monitorRepository.createOrder(createOrderDto);
await this.makeMonitoring(monitor);
return omit(monitor.toJSON(), ['__v', 'createdAt', 'updatedAt']);
}
async removeOrder(id) {
const monitor = await this.monitorRepository.getOrder(id);
// 若不存在則回傳 404 錯誤
if (!monitor) {
throw new NotFoundException('order not found');
}
// 移除監控設定並刪除觸價委託
await this.removeMonitor(monitor);
return this.monitorRepository.removeOrder(id);
}
...
private async checkMatches(message: any) {
// 非整股行情則結束函式
if (message.data.info.type !== 'EQUITY') return;
// 不包含最新成交價則結束函式
if (!message.data.quote.trade) return;
// 取出股票代號與最新成交價
const { symbolId: symbol } = message.data.info;
const { price } = message.data.quote.trade;
// 按股票代號及最新成交價檢查匹配的監控設定 ID
const matches = await Promise.all([
this.redis.zrange(`monitors:${symbol}:price:gt`, '-inf', price, 'BYSCORE'),
this.redis.zrange(`monitors:${symbol}:price:lt`, price, '+inf', 'BYSCORE'),
]).then(members => [].concat.apply([], members));
// 若無滿足條件的監控設定則結束函式
if (!matches.length) return;
// 按監控設定 ID 取出匹配的監控設定
const monitors = await this.redis.mget(matches)
.then(results => results.map(data => JSON.parse(data)));
for (const monitor of monitors) {
await this.removeMonitor(monitor); // 移除匹配的監控設定
// 若監控設定包含 alert 則推播訊息
if (monitor.alert) await this.sendAlert(monitor, message.data.quote);
// 若監控設定包含 order 則下單委託
if (monitor.order) await this.placeOrder(monitor, message.data.quote); // <--- 新增這行
}
}
private async placeOrder(monitor: MonitorDocument, quote: any) {
const { _id, symbol, order } = monitor;
const time = DateTime.fromISO(quote.trade.at).toFormat('yyyy/MM/dd HH:mm:ss');
// 設定推播訊息
const message = [
'',
`<<觸價委託>>`,
`股票代號: ${symbol}`,
`成交價: ${quote.trade.price}`,
`成交量: ${quote.total.tradeVolume}`,
`時間: ${time}`,
].join('\n');
// 透過 Trader Service 進行下單委託
this.traderService.emit('place-order', order);
// 透過 LINE Notify 推播訊息並將監控設定更新為已觸發
await this.lineNotify.send({ message })
.then(() => this.monitorRepository.triggerMonitor(_id))
.catch((err) => Logger.error(err.message, err.stack, MonitorService.name));
}
}
在 MonitorService
中,我們注入 ClientProxy
使用 TRADER_SERVICE
調用 Trader 應用程式的微服務。
「觸發委託」與「到價提醒」是基於相同的實現方式,差別在於觸發委託會確認 Monitor
監控物件是否包含 order
欄位;到價提醒則是檢查 alert
欄位。
在 MonitorService
的 placeOrder()
方法中,當觸價委託條件被觸發,Monitor Service 會向 Trader API 請求下單委託,並且透過 LINE Notify 將觸價委託訊息推播給使用者。
完成 Monitor Service 後,我們要加入觸價委託設定至 Monitor API 中。
開啟 apps/monitor/src/monitor/monitor.controller.ts
檔案,實作 MonitorController
加入以下方法:
getOrders()
:取得所有觸價委託。createOrder()
:建立觸價委託。removeOrder()
:移除觸價委託。調整 MonitorController
如下:
import { Controller, Get, Post, Delete, Body, Param, HttpCode } from '@nestjs/common';
import { MonitorService } from './monitor.service';
import { CreateAlertDto } from './dto/create-alert.dto';
import { CreateOrderDto } from './dto/create-order.dto';
@Controller('monitor')
export class MonitorController {
constructor(private readonly monitorService: MonitorService) {}
...
@Get('/orders')
async getOrders() {
return this.monitorService.getOrders();
}
@Post('/orders')
async createOrder(@Body() createOrderDto: CreateOrderDto) {
return this.monitorService.createOrder(createOrderDto);
}
@Delete('/orders/:id')
@HttpCode(204)
async removeOrder(@Param('id') id: string) {
return this.monitorService.removeOrder(id);
}
}
完成 MonitorController
後,就可以測試 Monitor API 了。首先啟動 Monitor 應用程式:
$ npm start monitor
然後在終端機透過 curl
指令來測試以下 API endpoints:
GET /monitor/orders
:建立觸價委託。POST /monitor/orders
:取得所有建立觸價委託。DELETE /monitor/orders/:id
:刪除觸價委託。使用以下 curl
指令建立一個到價提醒,當台積電(2330)股價突破 500 元時,觸發下單委託以 500 元 ROD 現股買進 1 張:
$ curl --request POST \
--url http://localhost:3000/monitor/orders \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data symbol=2330 \
--data type=price:gt \
--data value=500 \
--data 'order={"stockNo":"2330","buySell":"B","price":500,"quantity":1,"apCode":"1","priceFlag":"0","bsFlag":"R","trade":"0"}'
成功建立一個觸價委託,以 Monitor ID 6332f96d0d5a5cb977c849ba
為例:
{"symbol":"2330","type":"price:gt","value":"500","order":{"stockNo":"2330","buySell":"B","price":500,"quantity":1,"apCode":"1","priceFlag":"0","bsFlag":"R","trade":"0"},"triggered":false,"_id":"6332f96d0d5a5cb977c849ba"}
使用以下 curl
指令取得所有觸價委託:
$ curl --request GET \
--url http://localhost:3000/monitor/orders
成功取得所有觸價委託:
[{"_id":"6332f96d0d5a5cb977c849ba","symbol":"2330","type":"price:gt","value":"500","order":{"stockNo":"2330","buySell":"B","price":500,"quantity":1,"apCode":"1","priceFlag":"0","bsFlag":"R","trade":"0"},"triggered":false}]
使用以下 curl
指令刪除一個觸價委託,以 Monitor ID 6332f96d0d5a5cb977c849ba
為例:
$ curl --request DELETE \
--url http://localhost:3000/monitor/orders/6332f96d0d5a5cb977c849ba
完成後,我們就可以請求 Monitor API 新增觸價委託設定。當觸發下單委託條件時,Monitor Service 就會向 Trader API 發送下單請求,並透過 LINE Notify 發送推播訊息。當 Trader API 收到下單委託請求後,會立即執行下單動作,當收到委託回報時,Trader Service 會透過 LINE Notify 將回報訊息推播給使用者。
下圖是在交易日收盤後新增的觸價委託,設定條件是當台積電(2330)股價低於 450 元時,下單委託盤後定價買進 1 張台積電(2330)股票。
至此,我們已經示範如何實作自動化下單。透過富果行情與交易 API 的應用,可以依據事先設計好的進出場訊號,進行觸價委託設定。當條件觸發就可以立即讓程式代為執行下單委託。您可以根據自己的需求,自行加入其他觸發委託下單的條件,例如成交量、漲跌幅等,打造屬於自己的自動化交易系統。
本系列文已正式出版為《Node.js 量化投資全攻略:從資料收集到自動化交易系統建構實戰》。本書新增了全新內容和實用範例,為你提供更深入的學習體驗!歡迎參考選購,開始你的量化投資之旅!
天瓏網路書店連結:https://www.tenlong.com.tw/products/9786263336070